useRef와 전역 변수의 차이
useRef와 전역 변수의 차이
useRef와 모듈 전역 변수는 값을 바꿔도 React 렌더를 요청하지 않는다는 점만 같다. ref는 특정 컴포넌트 인스턴스가 소유하고 그 인스턴스의 생명주기를 따른다. 모듈 변수는 같은 JavaScript 실행 환경의 모든 인스턴스와, 서버에서는 여러 요청이 공유할 수 있다. 화면에 영향을 주는 값은 state, 인스턴스별 비시각적 값은 ref, 정말 애플리케이션 전체가 공유해야 하는 값만 별도 저장소에 둔다.
목차
- #둘 다 렌더를 일으키지 않는다는 오해
- #값의 소유자가 가장 큰 차이다
- #ref가 적합한 세 가지 사용 사례
- #ref 변경이 화면에 반영되지 않는 이유
- #렌더 중 ref를 읽거나 쓰지 않는다
- #전역 변수가 여러 인스턴스를 섞는 과정
- #SSR에서 사용자별 값을 전역에 두면 안 되는 이유
- #Strict Mode와 초기화 비용
- #ref로 최신 값을 읽는 패턴의 한계
- #공유 상태가 필요하면 명시적인 저장소를 사용한다
- #테스트와 리뷰 체크리스트
- #정리
- #관련 노트와 참고 자료
둘 다 렌더를 일으키지 않는다는 오해
렌더 사이에 값을 보존하되 값이 바뀌어도 재렌더를 원하지 않을 때 useRef를 쓴다는 설명을 자주 접한다. 그러면 다음 질문이 생긴다.
모듈 위에 변수 하나를 선언해도 값이 유지되고 재렌더되지 않는데, 굳이 ref를 써야 할까?
표면적으로 두 코드는 비슷해 보인다.
let renderCount = 0;
function GlobalCounter() {
renderCount += 1;
return <p>{renderCount}</p>;
}
function RefCounter() {
const renderCountRef = useRef(0);
renderCountRef.current += 1;
return <p>{renderCountRef.current}</p>;
}
두 번째 코드도 렌더 중 ref를 수정하므로 좋은 예제는 아니다. 여기서 중요한 것은 “값이 남는다”가 아니라 어디에 몇 개가 만들어지고 언제 폐기되는가다.
렌더 여부가 아니라 값의 소유권을 먼저 묻는다. 컴포넌트 인스턴스마다 따로 있어야 하면 ref, 화면이 반응해야 하면 state, 모든 소비자가 정말 하나를 공유해야 할 때만 외부 저장소를 고려한다.
값의 소유자가 가장 큰 차이다
같은 SearchBox를 두 개 렌더해 보자.
function SearchPage() {
return (
<>
<SearchBox label="상품 검색" />
<SearchBox label="브랜드 검색" />
</>
);
}
각 컴포넌트에서 ref를 만들면 서로 다른 ref 객체를 갖는다.
function SearchBox({ label }: { label: string }) {
const inputRef = useRef<HTMLInputElement>(null);
function handleFocus() {
inputRef.current?.focus();
}
return (
<div>
<label>
{label}
<input ref={inputRef} />
</label>
<button type="button" onClick={handleFocus}>
입력창으로 이동
</button>
</div>
);
}
상품 검색 버튼은 상품 입력에, 브랜드 검색 버튼은 브랜드 입력에 focus한다. ref는 각 컴포넌트가 차지한 트리 위치에 연결되어 있다.
반면 모듈 변수는 하나다.
let inputElement: HTMLInputElement | null = null;
function SearchBox({ label }: { label: string }) {
function handleFocus() {
inputElement?.focus();
}
return (
<div>
<label>
{label}
<input ref={(node) => { inputElement = node; }} />
</label>
<button type="button" onClick={handleFocus}>
입력창으로 이동
</button>
</div>
);
}
마지막으로 mount되거나 ref callback이 실행된 input이 전역 변수를 덮어쓴다. 두 버튼 모두 같은 input에 focus할 수 있고, 한 인스턴스가 unmount되며 null을 기록하면 남아 있는 인스턴스도 영향을 받는다.
| 속성 | useRef |
모듈 전역 변수 |
|---|---|---|
| 생성 단위 | 컴포넌트 인스턴스마다 | 모듈 실행 환경마다 |
| 생명주기 | mount된 인스턴스와 함께 | 모듈이 메모리에 있는 동안 |
| 여러 인스턴스 | 서로 격리됨 | 하나를 공유함 |
| 변경 시 렌더 | 발생하지 않음 | 발생하지 않음 |
| SSR 요청 격리 | 컴포넌트 렌더별 | 프로세스 안에서 공유될 수 있음 |
| React가 소유권을 앎 | 알고 있음 | 알지 못함 |
ref가 적합한 세 가지 사용 사례
1. DOM 노드 참조
focus, scroll, media 재생처럼 imperative DOM API를 호출할 때 사용한다.
function ErrorSummary({ errors }: { errors: FormError[] }) {
const summaryRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (errors.length > 0) {
summaryRef.current?.focus();
}
}, [errors.length]);
return (
<div ref={summaryRef} tabIndex={-1} aria-live="polite">
{errors.map((error) => (
<p key={error.field}>{error.message}</p>
))}
</div>
);
}
React가 DOM을 commit한 뒤 current에 노드를 넣고, 노드가 제거되면 null로 되돌린다. 렌더 중에는 아직 새 DOM이 commit되지 않았을 수 있으므로 event handler나 Effect에서 접근한다.
2. 외부 자원의 handle
timer ID, WebSocket 인스턴스, observer처럼 화면에 직접 표시되지 않는 mutable handle을 보관한다.
function AutoSaveEditor() {
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
function scheduleSave(content: string) {
if (timeoutRef.current !== null) {
clearTimeout(timeoutRef.current);
}
timeoutRef.current = setTimeout(() => {
void saveDraft(content);
}, 800);
}
useEffect(() => {
return () => {
if (timeoutRef.current !== null) {
clearTimeout(timeoutRef.current);
}
};
}, []);
return <Editor onChange={scheduleSave} />;
}
두 Editor 인스턴스가 있으면 timer도 각각 따로 관리된다. 전역 timer ID를 쓰면 한 편집기의 입력이 다른 편집기의 저장 예약을 취소할 수 있다.
3. 렌더 결과와 무관한 이전 정보
이전 prop과 현재 prop을 비교해 분석 로그를 남기는 용도로 사용할 수 있다.
function ProductView({ productId }: { productId: string }) {
const previousProductIdRef = useRef<string | null>(null);
useEffect(() => {
const previous = previousProductIdRef.current;
if (previous !== null && previous !== productId) {
analytics.track("product_changed", {
from: previous,
to: productId,
});
}
previousProductIdRef.current = productId;
}, [productId]);
return <ProductDetail productId={productId} />;
}
이 값은 UI를 결정하지 않고 Effect의 부수 작업에만 쓰인다.
ref 변경이 화면에 반영되지 않는 이유
ref는 일반 JavaScript 객체다.
type MutableRef<T> = {
current: T;
};
ref.current를 바꾸는 것은 React state update queue에 아무 작업도 추가하지 않는다.
function HiddenCounter() {
const countRef = useRef(0);
function handleClick() {
countRef.current += 1;
console.log(countRef.current);
}
return (
<button onClick={handleClick}>
화면에는 계속 {countRef.current}
</button>
);
}
클릭할 때 로그는 증가하지만 렌더가 다시 일어나지 않아 버튼 텍스트는 그대로다. 다른 이유로 부모가 재렌더하면 그때 변경된 값이 우연히 보일 수 있다. 이처럼 화면과 ref가 불규칙하게 동기화되는 코드는 피한다.
화면에 보여야 하는 값은 state다.
function VisibleCounter() {
const [count, setCount] = useState(0);
return (
<button onClick={() => setCount((current) => current + 1)}>
{count}
</button>
);
}
ref로 렌더를 피하면서 UI도 갱신하려는 요구는 모순이다. 사용자가 변경을 봐야 한다면 React가 다음 UI를 계산하도록 state를 사용한다.
렌더 중 ref를 읽거나 쓰지 않는다
React의 렌더 함수는 같은 입력에 대해 같은 JSX를 반환하는 순수 계산이어야 한다. 렌더 중 ref.current를 변경하면 호출 횟수에 따라 결과가 달라진다.
function RenderSequence() {
const sequenceRef = useRef(0);
sequenceRef.current += 1;
return <p>render {sequenceRef.current}</p>;
}
개발 Strict Mode에서 컴포넌트가 추가 호출되거나 concurrent rendering에서 render가 버려지면 화면에 commit된 횟수와 ref 값이 다를 수 있다.
렌더 중 DOM ref를 읽는 것도 안전하지 않다.
function Panel() {
const panelRef = useRef<HTMLDivElement>(null);
const width = panelRef.current?.getBoundingClientRect().width ?? 0;
return <div ref={panelRef}>너비: {width}</div>;
}
렌더 시점에는 새 DOM 변경이 아직 commit되지 않았고 ref가 null이거나 이전 DOM 측정값일 수 있다. layout 측정은 useLayoutEffect에서 하고, 측정 결과가 UI에 필요하면 state로 옮긴다.
function Panel() {
const panelRef = useRef<HTMLDivElement>(null);
const [width, setWidth] = useState(0);
useLayoutEffect(() => {
const panel = panelRef.current;
if (!panel) return;
const observer = new ResizeObserver(([entry]) => {
setWidth(entry.contentRect.width);
});
observer.observe(panel);
return () => observer.disconnect();
}, []);
return <div ref={panelRef}>너비: {Math.round(width)}</div>;
}
예외적으로 초기값을 만드는 코드가 순수하고 결과가 항상 같다면 렌더 중 current === null 검사로 지연 초기화할 수 있지만, 외부 부수 효과는 포함하지 않는다.
전역 변수가 여러 인스턴스를 섞는 과정
검색 요청의 순번을 전역으로 관리하는 코드를 보자.
let latestRequestId = 0;
function ProductSearch({ storeId }: { storeId: string }) {
const [products, setProducts] = useState<Product[]>([]);
async function search(query: string) {
const requestId = ++latestRequestId;
const result = await fetchProducts(storeId, query);
if (requestId === latestRequestId) {
setProducts(result);
}
}
return <SearchView products={products} onSearch={search} />;
}
화면에 매장 A와 매장 B 검색 컴포넌트가 동시에 있으면 서로 같은 latestRequestId를 쓴다. B에서 새 검색을 시작했다는 이유로 A의 정상 응답이 오래된 것으로 판정될 수 있다.
ref로 바꾸면 각 인스턴스가 자신의 요청 순서를 가진다.
function ProductSearch({ storeId }: { storeId: string }) {
const latestRequestIdRef = useRef(0);
const [products, setProducts] = useState<Product[]>([]);
async function search(query: string) {
const requestId = ++latestRequestIdRef.current;
const result = await fetchProducts(storeId, query);
if (requestId === latestRequestIdRef.current) {
setProducts(result);
}
}
return <SearchView products={products} onSearch={search} />;
}
요청 자체를 취소할 수 있다면 AbortController가 더 분명할 수 있다. ref에는 controller를 보관하고 다음 검색이나 unmount 때 취소한다.
const controllerRef = useRef<AbortController | null>(null);
async function search(query: string) {
controllerRef.current?.abort();
const controller = new AbortController();
controllerRef.current = controller;
const result = await fetchProducts(query, {
signal: controller.signal,
});
setProducts(result);
}
SSR에서 사용자별 값을 전역에 두면 안 되는 이유
브라우저에서는 탭마다 JavaScript 환경이 대체로 분리되어 있다. 서버 렌더링 환경에서는 하나의 Node.js 프로세스가 여러 사용자의 요청을 처리하고 모듈 cache를 공유한다.
let currentUser: User | null = null;
export async function renderPage(request: Request) {
currentUser = await authenticate(request);
return renderToString(<App user={currentUser} />);
}
요청 A가 currentUser = Alice를 기록한 뒤 렌더 중 대기하고, 요청 B가 Bob으로 덮어쓰면 A의 후속 코드가 Bob을 읽을 수 있다.
sequenceDiagram
participant A as 요청 A
participant G as 모듈 전역
participant B as 요청 B
A->>G: currentUser = Alice
B->>G: currentUser = Bob
A->>G: currentUser 읽기
G-->>A: Bob사용자 정보, locale, 권한, request ID를 모듈 변수에 두면 요청 간 데이터 유출로 이어질 수 있다. 요청 값을 props나 request-scoped context로 명시적으로 전달한다.
export async function renderPage(request: Request) {
const user = await authenticate(request);
const requestContext = createRequestContext({ request, user });
return renderToString(
<RequestContext.Provider value={requestContext}>
<App />
</RequestContext.Provider>,
);
}
프레임워크가 제공하는 request scope나 AsyncLocalStorage를 사용할 수도 있지만 생명주기와 비동기 경계가 정확히 전파되는지 확인해야 한다.
변경 불가능한 설정이나 사용자와 무관한 제한된 cache는 모듈 범위에 둘 수 있다. 사용자별 인증·권한·입력은 절대 프로세스 전역 공유 상태로 두지 않는다.
Strict Mode와 초기화 비용
다음 코드는 useRef(new VideoPlayer())를 호출할 때마다 생성자 표현식을 평가한다.
function Video() {
const playerRef = useRef(new VideoPlayer());
return <PlayerView player={playerRef.current} />;
}
React는 첫 렌더 이후 전달한 initial value를 무시하지만 new VideoPlayer() 표현식 자체는 함수가 호출될 때 실행된다. 생성 비용이 크거나 자원을 여는 생성자라면 문제가 된다.
순수한 지연 초기화는 다음처럼 할 수 있다.
function Video() {
const playerRef = useRef<VideoPlayer | null>(null);
if (playerRef.current === null) {
playerRef.current = new VideoPlayer();
}
return <PlayerView player={playerRef.current} />;
}
이 패턴은 결과가 예측 가능하고 렌더 외부에 부수 효과를 만들지 않을 때만 사용한다. DOM listener 등록, network connection, 전역 registry 변경은 Effect에서 만들고 cleanup한다.
useEffect(() => {
const player = new RemotePlayer();
player.connect();
playerRef.current = player;
return () => {
player.disconnect();
playerRef.current = null;
};
}, []);
Strict Mode에서 ref 객체가 개발 중 두 번 만들어질 수 있지만 한쪽은 버려진다. 컴포넌트가 순수하다면 결과에 영향을 주지 않는다.
ref로 최신 값을 읽는 패턴의 한계
오래 유지되는 외부 callback이 최신 props를 읽게 하려고 ref를 쓸 수 있다.
function KeyboardShortcut({ onSave }: { onSave: () => void }) {
const onSaveRef = useRef(onSave);
useEffect(() => {
onSaveRef.current = onSave;
}, [onSave]);
useEffect(() => {
function handleKeyDown(event: KeyboardEvent) {
if ((event.metaKey || event.ctrlKey) && event.key === "s") {
event.preventDefault();
onSaveRef.current();
}
}
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, []);
}
이렇게 하면 onSave 함수 참조가 바뀔 때마다 전역 listener를 다시 등록하지 않는다. 하지만 남용하면 Effect가 어떤 값 변화에 반응하는지 코드에서 보이지 않는다.
다음 경우에는 다른 해법이 낫다.
- 이전 state에서 다음 state를 계산한다 → setter updater
- prop 변화에 따라 외부 연결도 달라져야 한다 → 정직한 Effect dependency
- 렌더에 값이 필요하다 → state
- 여러 컴포넌트가 구독해야 한다 → Context나 external store
사용하는 React 버전이 Effect Event를 안정적으로 제공한다면 최신 callback 읽기를 더 명시적으로 표현할 수 있다. 버전별 API 상태는 발행 시 공식 문서를 확인한다.
공유 상태가 필요하면 명시적인 저장소를 사용한다
전역 변수가 항상 나쁜 것은 아니다. 애플리케이션 전체에 하나만 있어야 하는 logger, 변경 불가능한 설정, 명시적인 cache 인스턴스는 모듈 범위가 자연스러울 수 있다.
문제는 변경 가능한 전역 값을 UI에서 직접 읽고 쓰면서 구독과 소유권이 없는 경우다.
let selectedWorkspaceId = "";
export function selectWorkspace(id: string) {
selectedWorkspaceId = id;
}
값이 바뀌어도 React는 어떤 컴포넌트를 다시 렌더해야 하는지 모른다. 테스트 사이에 값도 남을 수 있다.
공유 상태라면 변경 API, 구독, snapshot 읽기를 가진 store로 만든다.
type Listener = () => void;
class WorkspaceStore {
private selectedId: string | null = null;
private readonly listeners = new Set<Listener>();
getSnapshot = () => this.selectedId;
subscribe = (listener: Listener) => {
this.listeners.add(listener);
return () => this.listeners.delete(listener);
};
select(id: string) {
if (this.selectedId === id) return;
this.selectedId = id;
this.listeners.forEach((listener) => listener());
}
}
React에서는 useSyncExternalStore로 구독할 수 있다.
function useSelectedWorkspace(store: WorkspaceStore) {
return useSyncExternalStore(
store.subscribe,
store.getSnapshot,
store.getSnapshot,
);
}
실제로는 검증된 상태 관리 라이브러리나 Context가 더 적합할 수 있다. 핵심은 “전역 변수라서 어디서나 접근 가능”이 아니라 소유권, 변경 규칙, 구독, SSR 격리를 명시하는 것이다.
테스트와 리뷰 체크리스트
여러 인스턴스가 격리되는지 테스트하면 전역 변수 실수를 잡을 수 있다.
it("각 검색창의 focus 대상이 서로 독립적이다", async () => {
const user = userEvent.setup();
render(
<>
<SearchBox label="상품 검색" />
<SearchBox label="브랜드 검색" />
</>,
);
await user.click(
screen.getByRole("button", { name: "브랜드 입력창으로 이동" }),
);
expect(screen.getByLabelText("브랜드 검색")).toHaveFocus();
expect(screen.getByLabelText("상품 검색")).not.toHaveFocus();
});
timer와 observer는 unmount 후 정리되는지 확인한다. SSR 관련 코드는 서로 다른 사용자 요청을 동시에 실행해 데이터가 섞이지 않는지 통합 테스트한다.
- 값이 바뀌면 화면도 바뀌어야 하는가? → state
- 컴포넌트 인스턴스마다 따로 필요한가? → ref
- DOM 또는 외부 자원의 handle인가? → ref + cleanup
- 여러 컴포넌트가 변경을 구독해야 하는가? → Context/store
- 서버 요청별 데이터인가? → request scope
- 정말 프로세스 전체에 하나여야 하는가? → 명시적인 global service/cache
정리
useRef와 모듈 전역 변수는 모두 변경 시 렌더를 요청하지 않지만 소유 범위가 완전히 다르다. ref는 React 트리 안의 특정 컴포넌트 인스턴스에 속하고, 모듈 변수는 같은 실행 환경의 모든 인스턴스가 공유한다.
- DOM 노드와 timer·observer handle은 ref에 보관한다.
- 화면을 결정하는 값은 state를 사용한다.
- 렌더 중 ref를 읽고 써서 출력 결과를 만들지 않는다.
- 여러 컴포넌트 인스턴스가 섞이면 안 되는 값을 전역에 두지 않는다.
- SSR의 사용자별 데이터는 request scope로 격리한다.
- 진짜 공유 상태는 구독 가능한 Context나 external store로 모델링한다.
ref는 렌더를 피하기 위한 전역 변수 대체품이 아니라, 컴포넌트 인스턴스가 소유하는 비시각적 mutable 값을 보관하는 escape hatch다.